---
title: 延迟补偿
slug: latency-compensation
date: 0007/01/02
number: 7.5
points: 5
sidebar: true
photoUrl: http://www.flickr.com/photos/ikewinski/9473352049/
photoAuthor: Mike Lewinski
contents: 理解延迟补偿（Latency Compensation）。|减慢你的 App 看看发生了什么。|学习 Meteor 的内置方法如何互相调用。
paragraphs: 28
---

在上一章，我们介绍了 Meteor 的一个新概念：**内置方法**。

<%= diagram "latency1", "没有延迟补偿的情况", "pull-right" %>

Meteor 的内置方法是一种在服务器上执行一系列命令的结构化方法。在示例中，我们使用内置方法是为了确保新帖子是通过作者的姓名和 ID ，以及当前服务器时间去标记。

然而，如果 Meteor 用最基本的方式去执行内置方法，我们会注意到一些问题。想一想下面事件的序列（注：为演示方便，时间戳值是随机的生成的）：

- *+0ms:* 用户单击提交按钮，浏览器触发内置方法的调用。
- *+200ms:* 服务器更改 Mongo 数据库。
- *+500ms:* 客户端接收到这些变化，并更新页面反映更改结果。

如果这是 Meteor 的操作方式，它会有一个很短的时间差去看到这样的执行操作的结果（延时的多少会取决于你的服务器性能）。但我们不可能让这些情况出现在 Web 应用程序中！

### 延迟补偿

<%= diagram "latency2", "延迟补偿的情况", "pull-right" %>

为了避免这个问题，Meteor 引入了一个叫做**延迟补偿（Latency Compensation）**的概念。如果我们把 `post` 方法的定义放在 `collections/` 目录下。这意味着它在服务端和客户端上都存在，而且是同时运行！

当你使用内置方法的时候，客户端会发送请求到服务器去调用，同时还模仿服务器内置方法去操作本地数据库集合。所以现在我们的工作流程是：

- *+0ms:* 用户单击提交按钮，浏览器触发内置方法的调用。
- *+0ms:* 客户端模仿内置方法操作本地的数据集合和以及通过更改页面来反映这一变化。
- *+200ms:* 服务器更改 Mongo 数据库。
- *+500ms:* 客户端接收这些更改并取消刚刚的模仿操作，根据服务器的更改覆盖它们（通常是相同的）。页面的变化反映了这一过程。

这样用户就会立刻看到变化。服务器的响应返回一段时间后，根据服务器数据库发送过来的更改请求，本地数据库可能会或可能不会有明显的改变。因此，我们应该学会确保本地数据尽可能地与服务器数据库保持一致。

### 观察延迟补偿

我们可以对 `post` 内置方法的调用稍作改动。为此，我们将会通过 npm 包 `futures` ，使用一些高级的编程方式去把延迟对象放到我们的内置方法调用里面。

我们将使用 `isServer` 去问 Meteor 现在所调用的内置方法是在客户端被调用（作为一个存根 Stub）或是在服务器端。这个存根 [stub](http://docs.meteor.com/#methods_header) 是模仿内置方法在客户端运行的模拟方法，而“真正的”内置方法是在服务器上运行的。

所以我们会询问 Meteor 这部分代码是否在服务器端执行。如果是，我们会在帖子的标题后面添加 `(server)` 字符串。如果不是，我们将添加 `(client)` 字符串：

~~~js
Posts = new Mongo.Collection('posts');

Meteor.methods({
  postInsert: function(postAttributes) {
    check(this.userId, String);
    check(postAttributes, {
      title: String,
      url: String
    });

    if (Meteor.isServer) {
      postAttributes.title += "(server)";
      // wait for 5 seconds
      Meteor._sleepForMs(5000);
    } else {
      postAttributes.title += "(client)";
    }

    var postWithSameLink = Posts.findOne({url: postAttributes.url});
    if (postWithSameLink) {
      return {
        postExists: true,
        _id: postWithSameLink._id
      }
    }
    
    var user = Meteor.user();
    var post = _.extend(postAttributes, {
      userId: user._id, 
      author: user.username, 
      submitted: new Date()
    });
    
    var postId = Posts.insert(post);
    
    return {
      _id: postId
    };
  }
});
~~~
<%= caption "collections/posts.js" %>
<%= highlight "11~17" %>

如果我们到此为止，这个演示就不那么有意义。当前，看起来就像是帖子表单提交后暂停了5秒钟，然后转到主帖子列表，没发生其他事情。

为了理解这是为什么，让我们看看帖子提交的事件 handler：

~~~js
Template.postSubmit.events({
  'submit form': function(e) {
    e.preventDefault();
    
    var post = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val()
    };
    
    Meteor.call('postInsert', post, function(error, result) {
      // display the error to the user and abort
      if (error)
        return alert(error.reason);
      
      // show this result but route anyway
      if (result.postExists)
        alert('This link has already been posted');
    
      Router.go('postPage', {_id: result._id});  
    });
  }
});
~~~
<%= caption "client/templates/posts/post_submit.js" %>

我们在方法 call 回调函数中放了 `Router.go()` 路由函数。

现在的行为通常是正确的。毕竟，你不能在确定他们帖子提交是否有效之前去跳转用户，只是因为如果立即跳转走，然后几秒钟后再转回到原始帖子页面去更正数据，这会非常令人困惑。

但是对于这个例子而言，我们想立即看看结果。所以我们将路由更改到 `postsList` 路由（我们还不能路由到帖子，因为在方法之外我们不知道帖子的 `_id`），把它从回调函数中移出来，看看会发生什么：

~~~js
Template.postSubmit.events({
  'submit form': function(e) {
    e.preventDefault();
    
    var post = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val()
    };
    
    Meteor.call('postInsert', post, function(error, result) {
      // display the error to the user and abort
      if (error)
        return alert(error.reason);
      
      // show this result but route anyway
      if (result.postExists)
        alert('This link has already been posted');
    });

    Router.go('postsList');  

  }
});
~~~
<%= caption "client/templates/posts/post_submit.js" %>
<%= highlight "20" %>

<%= scommit "7-5-1", "Demonstrate the order that posts appear using a sleep." %>

如果我们现在创建一个帖子，我们可以清楚地看到延迟补偿。首先，插入一个标题带 `(client)` 的帖子（列表的第一个帖子，链接到 GitHub）：

<%= screenshot "s5-1", "我们的帖子首先储存在客户端集合" %>

接着，五秒之后，它就会被服务器插入的真正帖子文档所替代：

<%= screenshot "s5-2", "我们的帖子在客户端收到来自服务器端集合传来的更新" %>

### 客户端集合内置方法

通过上面所说的，你可能会认为内置方法很复杂，但事实上它们也可以相当简单。实际上我们已经用过三个非常简单的内置方法：集合的操作方法 `insert`、`update` 和 `remove`。

当你定义一个服务器集合称为 `'posts'` ，你已经隐式地定义了这三个内置方法： `posts/insert`、`posts/update` 和 `posts/delete`。换句话说，当你在本地集合中调用 `Posts.insert()`，你已经在调用延时补偿方法来做下面这两件事：

1. 检查我们是否允许通过调用 `allow` 和 `deny` 方法的回调去操作集合（然而这并不需要发生在内置方法的模拟）。
2. 实际地修改底层的数据库。
  
### 内置方法的相互调用

你可能已经意识到当我们插入帖子的时候，`post` 的内置方法调用了另一个内置方法（`posts/insert`）。这是如何工作的呢？

当模拟方法（客户端版本的内置方法）开始运行，模拟方法执行 `insert`（插入的是本地集合）时，我们不叫它真正的服务器端 `insert`，但我们会认为*服务器端*的 `post` 也将会同样的被插入。

因此，当服务器端的 `post` 调用内置方法 `insert` 的时候更加没有必要去担心的客户端模拟方法，它肯定能够在客户端顺利地插入。

像之前一样，在阅读下一章之前，不要忘记还原你所做的更改。